﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace GoodStuff.Security
{
    /// <summary>
    /// FloodGate class will throw exceptions when a particular
    /// IP address posts too many operations in a given timeframe.
    /// 
    /// Add a static member of this class to your business operations.
    /// </summary>
    /// <example>
    /// <code>
    /// public class pageController
    /// {
    ///     private static FloodGate loginGate = new FloodGate(TimeSpan.FromMinutes(5), 3, TimeSpan.FromMinutes(10), p => new FloodingExecption("Login"));
    ///     
    ///     void performLogin(string username, string password)
    ///     {
    ///         if(!verifyPassword(username, password))
    ///         {
    ///            //throws an FloodingException when login failed more than 3 times within 5 minutes. Also, the IP is blocked for 10 minutes. 
    ///             loginGate.Assert(true);
    ///             //...
    ///         }
    ///         else
    ///         {
    ///             //....
    ///         }
    ///     }
    /// }
    /// </code>
    /// </example>
    public class FloodGate
    {
        private Dictionary<string, Attempt> _operators;
        //private string _operationName;
        private Func<string,Exception> _throw;
        private TimeSpan _minimumDelayBetweenOperations;
        private TimeSpan _lockoutDuration;
        private int _maximumAttempts;

        /// <summary>        
        /// </summary>
        /// <param name="minimumDelayBetweenOperations">The amount of time that must have passed between operations by the same IP address.</param>
        /// <param name="maximumAttemps">Number of asserts that can be called within the timespan before throwing an error. E.g. 3 will throw on 3rd attempt.</param>
        /// <param name="onError">A delegate that generates an exception based on a client IP string.</param>
        public FloodGate(TimeSpan minimumDelayBetweenOperations, int maximumAttemps, TimeSpan lockoutDuration, Func<string, Exception> onError)
        {
            _operators = new Dictionary<string, Attempt>();
            _throw = onError;
            _minimumDelayBetweenOperations = minimumDelayBetweenOperations;
            _lockoutDuration = lockoutDuration;
            _maximumAttempts = maximumAttemps;
        }

        public FloodGate(TimeSpan minimumDelayBetweenOperations, int maximumAttemps, Func<string, Exception> onError)
            :this(minimumDelayBetweenOperations, maximumAttemps, minimumDelayBetweenOperations, onError)
        {
        }

        /// <param name="operationName">The name of the operation used for logging purposes</param>
        /// <param name="minimumDelayBetweenOperations">The amount of time that must have passed between operations by the same IP address.</param>
        public FloodGate(string operationName, TimeSpan minimumDelayBetweenOperations)
            : this(minimumDelayBetweenOperations, 1, p => new FloodingException(operationName))
        {
        }

        /// <summary>
        /// Call Assert for each time an operation is performed or attempted.  
        /// </summary>
        /// <exception>
        /// If the limit is reached, this method will execute the onError handler, which by default throwing a FloodingException.
        /// </exception>
        public void Assert()
        {
            this.Assert(true);
        }
        
        /// <summary>
        /// Similar to the <see cref="Assert"/> method, but with a boolean argument to indicate a operation has failed.
        /// </summary>
        /// <param name="countAsFail">When true, the operation is marked as a fail and adds one to the number of failed attempts. If the number of failed attempts is equal or larger than
        /// the configured maximum attempts, the onError delegate is executed and an exception is thrown.</param>
        public void Assert(bool countAsFail)
        {
            //check when the last operation was done.
            if (System.Web.HttpContext.Current != null)
            {
                Cleanup();

                string clientip = System.Web.HttpContext.Current.Request.UserHostAddress;
                if (_operators.ContainsKey(clientip))
                {
                    Attempt attempt = _operators[clientip];

                    if (countAsFail)
                    {
                        attempt.Counter++;
                    }

                    if (attempt.Counter >= _maximumAttempts)
                    {
                        attempt.Expires = DateTime.Now + _lockoutDuration;

                        //the operation didn't pass the 'clean' so it is invalid.
                        throw _throw(clientip);
                    }
                    else
                    {
                        attempt.Expires = DateTime.Now + _minimumDelayBetweenOperations;
                    }
                }
                else
                {
                    //add the timestamp of this operation.
                    _operators[clientip] = new Attempt() { Counter = (countAsFail)?1:0, Expires = DateTime.Now + _minimumDelayBetweenOperations };
                }
            }            
        }
    
        /// <summary>
        /// Performs an internal cleanup by removing expired records.
        /// </summary>
        private void Cleanup()
        {
            //cleanup all expired operators to preserve memory.
            var itemsToRemove = _operators.Where(p => DateTime.Now > p.Value.Expires).ToList();
            foreach (var item in itemsToRemove)
            {
                _operators.Remove(item.Key);
            }
        }

        /// <summary>
        /// Internal support class to memorize floodgate attempts.
        /// </summary>
        private class Attempt
        {
            public DateTime Expires;
            public int Counter;
        }
    }

    /// <summary>
    /// The default Exception class raised when a floodgate reached its limits.
    /// </summary>
    public class FloodingException : Exception
    {
        public FloodingException(string message)
            : base(message)
        {
        }
    }
}
